BUGFIX: template-no-empty-headings — recognize boolean aria-hidden#2717
Draft
johanrd wants to merge 10 commits intoember-cli:masterfrom
Draft
BUGFIX: template-no-empty-headings — recognize boolean aria-hidden#2717johanrd wants to merge 10 commits intoember-cli:masterfrom
johanrd wants to merge 10 commits intoember-cli:masterfrom
Conversation
Before: isHidden only matched aria-hidden="true" as a string literal.
Boolean / valueless / empty / mustache forms (<h1 aria-hidden />,
<h1 aria-hidden="" />, <h1 aria-hidden={{true}} />) slipped past as
"not hidden", so empty headings in those forms were flagged as empty
even when the author had intentionally hidden them from AT.
Fix: extract isAriaHiddenTruthy(). Recognize:
- valueless attribute (HBS AST has value=null or empty-string TextNode)
- "true" string literal (preserved)
- "" empty string
- {{true}} boolean mustache literal
- {{"true"}} string mustache literal
Per HTML boolean-attribute semantics (and jsx-a11y/vue-a11y convention),
presence of aria-hidden without an explicit "false" value is treated as
truthy. The strict ARIA spec treats bare aria-hidden as "undefined"
rather than "true", but every major linter in the ecosystem (and most
screen readers) treats it as true.
Four new test cases covering each of the recognized forms.
…itively HTML attribute value comparison is ASCII case-insensitive per spec, so `aria-hidden="TRUE"` and `aria-hidden="True"` (and their mustache-string equivalents) should be recognised as truthy. Mirrors the same case- handling choice made in ember-cli#2718 for `kind="captions"`. Tests cover `"TRUE"`, `"True"`, `{{"TRUE"}}`, `{{"True"}}`.
Adds invalid tests for `aria-hidden={{false}}` and `aria-hidden={{"false"}}`
to lock down that falsy mustache values do not exempt an otherwise-empty
heading.
…ARIA spec
Per WAI-ARIA 1.2 §6.6, `aria-hidden` has value type true/false/undefined
with default `undefined`. Per §8.5, missing or empty-string attribute
values resolve to the default. So a valueless `aria-hidden` is NOT
hidden per spec — only an explicit `"true"` (ASCII case-insensitive per
HTML enumerated-attribute rules) hides the element.
The earlier direction of this PR borrowed the HTML boolean-attribute
intuition (presence = truthy) from jsx-a11y. That's a peer-plugin
convention, not a spec mandate — aria-hidden is an enumerated ARIA
attribute, not a boolean HTML one. vue-a11y's heading-has-content
doesn't exempt aria-hidden headings at all; lit-a11y has the inverse
rule.
Behaviour now:
- Exempt (hidden): `aria-hidden="true"` / "TRUE" / "True", `{{true}}`,
`{{"true"}}` / case-variants.
- Flag (NOT hidden per spec): valueless `<h1 aria-hidden>`, empty
`<h1 aria-hidden="">`, `{{false}}`, `{{"false"}}`, `"false"`.
johanrd
added a commit
to johanrd/eslint-plugin-ember
that referenced
this pull request
Apr 21, 2026
…idden default Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string aria-hidden resolves to default `undefined` — NOT `true`. Valueless `<button aria-hidden>` and empty `<button aria-hidden="">` are therefore NOT spec-hidden; they do not create a focus-trap anti-pattern and the rule should not flag them. The prior behavior inherited jsx-a11y's convention (jsx-ast-utils coerces valueless JSX attrs to boolean true) and vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin conventions, not normative ARIA interpretations. Matching ember-cli#2717's spec-first resolution. Also corrects the rule-doc comment: the claim attributed to WAI-ARIA 1.2 ("Authors SHOULD NOT use aria-hidden='true' on any element that has focus or may receive focus") is not in the WAI-ARIA spec. The spec only says authors MAY "with caution" use aria-hidden. The rule's concern (keyboard trap) comes from community/axe guidance, which this comment now accurately attributes. Net: flagged values are now `aria-hidden="true"` (ASCII case-insensitive), `aria-hidden={{true}}`, and `aria-hidden={{"true"}}`. Valueless, empty, `false`, and `{{false}}` are all accepted.
johanrd
added a commit
to johanrd/eslint-plugin-ember
that referenced
this pull request
Apr 21, 2026
…ec + correct peer-plugin claims
Two corrections to the previous revision:
1. Valueless / empty-string `aria-hidden` is no longer treated as a
non-interactive escape hatch. Per WAI-ARIA 1.2 §6.6 + aria-hidden
value table, a missing or empty-string value resolves to the default
`undefined` — NOT `true`. Only an explicit `aria-hidden="true"`
(ASCII case-insensitive) or mustache-literal `{{true}}` opts out.
This matches ember-cli#2717 / #19's spec-first resolution.
2. Code comment corrections. jsx-a11y's util is named
`isPresentationRole`, not `hasPresentationRole`. The comment also
claimed jsx-a11y's `isPresentationRole` does "first token of a
space-separated role list" — it does not (jsx-a11y does plain
`presentationRoles.has(rawValue)`, no trim/lowercase/split). Our
first-token behavior is a deliberate superset, not parity.
Moved `<div aria-hidden onclick>` and `<div aria-hidden="" onclick>` from
the valid section to invalid. Added `<div aria-hidden="TRUE">` as
additional valid coverage for the case-insensitive path.
johanrd
added a commit
to johanrd/eslint-plugin-ember
that referenced
this pull request
Apr 21, 2026
…ML boolean semantics
Per HTML Living Standard on boolean attributes, the presence of `autofocus`
indicates TRUE regardless of value — `autofocus="false"` and
`autofocus="autofocus"` are equally truthy. jsx-a11y's `no-autofocus`
treats the literal string `"false"` as an opt-out (via `getPropValue`),
but that's a peer-plugin convention that diverges from HTML semantics;
vue-a11y and lit-a11y are presence-based, consistent with the spec.
Narrow opt-out to the only case that is spec-consistent:
- `autofocus={{false}}` in angle-bracket syntax — renders no attribute.
- `{{input autofocus=false}}` in mustache hash-pair syntax — no attribute.
Revert peer-parity opt-outs for `autofocus="false"`, `autofocus={{"false"}}`,
and `{{input autofocus="false"}}` — these are now flagged per HTML spec
semantics. Moved from valid → invalid in the test suite.
Dialog exemption unchanged — keeps MDN-backed behavior for autofocus on
and within <dialog>.
Follows the spec-first direction established in ember-cli#2717 (aria-hidden),
#19, #33.
johanrd
added a commit
to johanrd/eslint-plugin-ember
that referenced
this pull request
Apr 21, 2026
…I-ARIA spec Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string aria-hidden resolves to the default `undefined` — NOT `true`. So <span aria-hidden>X</span> as a child of <a href="/x"> does NOT hide the span; its content still contributes to the anchor's accessible name. The prior behavior inherited jsx-a11y's JSX-coercion convention and vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin conventions that diverge from normative ARIA. Matches the spec-first resolution of ember-cli#2717, #19, and #33. Moved valueless / empty aria-hidden cases from invalid → valid. Kept the explicit aria-hidden="true" and {{true}} cases as invalid.
…less aria-hidden
The valueless / empty-string aria-hidden case is genuinely contested in
the ecosystem — four positions exist (jsx-a11y / vue-a11y / axe-core /
WAI-ARIA spec), and no single authoritative source is decisive. Rather
than pick one interpretation and live with its false positives, this
rule leans toward fewer-false-positives: any aria-hidden form that could
plausibly mean "hide this" exempts the heading from the empty-content
check.
Truthy (exempt heading):
- valueless `<h1 aria-hidden>` — undefined-default per spec, but
authors who write bare aria-hidden plausibly intend to hide.
- empty `<h1 aria-hidden="">` — same.
- `aria-hidden="true"` (ASCII case-insensitive) — unambiguous.
- `aria-hidden={{true}}` / `{{"true"}}` (case-insensitive) — unambiguous.
Falsy (still flag empty heading):
- `aria-hidden="false"`, `{{false}}`, `{{"false"}}` — explicit opt-out.
This reverses the previous spec-first direction on the valueless/empty
case. Rationale: a linter that flags intentional decorative markup
creates friction and loss of trust; a linter that misses some genuinely-
empty headings is preferable when the signal is ambiguous. The explicit
`aria-hidden="true"` cases, which ARE clearly hidden per spec, remain
exempt.
Move the explanation of valueless / empty-string aria-hidden handling from the PR body into the published rule docs. The rule deviates from WAI-ARIA 1.2 §aria-hidden (which resolves valueless aria-hidden to the default 'undefined', not 'true') in order to favor fewer false positives for this specific check. Also document the 'opposite-direction' split with template-no-aria-hidden-on-focusable / template-anchor-has-content (where spec-literal interpretation applies), and the unambiguous cases that always follow the spec.
`isAriaHiddenTruthy` previously only handled raw TextNode and bare
MustacheStatement attribute values. The quoted-mustache form
`aria-hidden="{{true}}"` produces a `GlimmerConcatStatement` with a
single mustache part — resolve that case by descending into the single
static-literal part, mirroring the pattern established in
template-no-aria-hidden-focusable.
Leans toward "truthy" only on literal true / empty / bare-valueless to
match the rule's doc-stated ethos of fewer false positives.
0bcced1 to
a02b3e9
Compare
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR is part of a Phase 3 a11y-parity audit against jsx-a11y / vue-a11y / angular-eslint-template / lit-a11y.
aria-hiddenis intentionally invisible to assistive technology — requiring text content in that heading is a false positive.isHiddencheck only matchedaria-hidden="true"as a case-sensitive string literal, so<h1 aria-hidden>,<h1 aria-hidden="">,<h1 aria-hidden="TRUE">,<h1 aria-hidden={{true}}>, etc. were treated as visible and flagged for missing content.Fix: extract
isAriaHiddenTruthy()that recognizes every plausible "hide-this" form (valueless, empty-string,"true"case-insensitive, mustache boolean/string-literal true/case-variants).Prior art
heading-has-content<h1>–<h6>; skips hidden headings viaisHiddenFromAT(jsx-a11y'saria-hiddeninterpretation — see Contested-semantics table below).heading-has-content<h1>–<h6>; usesisHiddenFromScreenReader(vue's(value || "").toString() !== "false"— see Contested-semantics table below).elements-content<h1>–<h6>together with<a>/<button>via a single regex matcher; skips hidden viaisHiddenFromScreenReader. Not a dedicated heading rule.heading-hidden(headings must not be hidden) but no "heading has content" rule. Inverse concern.Four ecosystem positions on valueless aria-hidden
The question "what does
<el aria-hidden>(bare),aria-hidden=""(empty), oraria-hidden={{false}}mean?" has no single authoritative answer. Four defensible positions exist:jsx-ast-utilscoercing valueless JSX attrs to booleantrue, combined with rule checkariaHidden === true. Quirk: stringaria-hidden="true"is NOT recognized because"true" !== true. Not a deliberate ARIA interpretation."false"→ hiddenisHiddenFromScreenReader.ts:(value || "").toString() !== "false". Catches valueless, empty,"TRUE","anything". Non-spec shortcut.undefined→ not hiddentrue/false/undefined (default). Missing/empty resolves to the default.aria-hiddenis NOT an HTML boolean attribute — the HTML spec never designates it as such.Browser testing shows disagreement even on the explicit
aria-hidden="true"case (see Steve Faulkner's post and Mozilla bug 948540); no documented browser testing on valueless specifically — most likely a no-op matching the spec's undefined-default.Design choice for this rule
We lean toward fewer false positives. A linter that flags a heading the author intentionally marked decorative (via bare
aria-hidden) creates friction and loss of trust; a linter that silently accepts some genuinely-empty unhidden headings is the smaller cost when the signal is ambiguous. So any aria-hidden form that could plausibly mean "hide this" exempts the heading from the empty-content check.Exempt (don't flag empty heading):
<h1 aria-hidden>,<h1 aria-hidden="">,<h1 aria-hidden="true">,"TRUE","True"<h1 aria-hidden={{true}}>,{{"true"}},{{"TRUE"}},{{"True"}}Still flag (explicit opt-in to the content check):
<h1 aria-hidden="false">,{{false}},{{"false"}}Tests
Valid (heading exempted):
"true"/"TRUE"/"True",{{true}},{{"true"}}/ case-variantsInvalid (falsy explicitly → flagged):
<h1 aria-hidden="false"></h1>,{{false}},{{"false"}}